當我們為自己的 CLI 工具整合 AI 對話功能時,通常希望提供一個能持續輸入訊息、即時看到回答的互動介面。 LLM API(如 Claude 或 OpenAI)本質上是完全無狀態的(Stateless)——伺服器不會主動幫我們保留任何上下文歷史。
要讓使用者擁有流暢的連續對話體驗,CLI 工具必須處理好四項關鍵任務:
/reset 重置。這種「讀取輸入、求值回應、印出結果、繼續迴圈」的互動結構,即是 Human-friendly CLI 的持續互動:REPL 模式 的真實進階應用。這篇教學的重點不在於特定 AI 廠商的 SDK 語法,而是如何掌控連續互動型 CLI(Continuous Interactive CLI)的體驗與架構設計。
完整程式位於 cli-sample/claude-chat/ 目錄:
cli-sample/claude-chat/
├── cmd/
│ ├── root.go
│ └── chat.go
├── internal/chat/
│ └── session.go
├── go.mod
├── go.sum
└── main.go
切換到專案目錄並設定 ANTHROPIC_API_KEY 環境變數後,執行 go run . chat 即可啟動對話:
cd cli-sample/claude-chat
export ANTHROPIC_API_KEY=sk-ant-...
go run . chat
啟動 chat 子命令後,終端機呈現的互動流程如下:
$ go run . chat
you› 幫我把這段 JSON 轉成 Go struct
ai › 好的,你可以這樣定義……(文字片段持續輸出)
you› 幫欄位加上 json tag
ai › (記得上一輪的 struct,直接接著修改)
you› /reset # 清空歷史對話,重新開始
you› /exit # 離開 REPL 迴圈
在第二輪對話中,使用者只輸入「幫欄位加上 json tag」,並沒有重新貼上 struct 程式碼。AI 之所以能接續上下文,是因為 CLI 在客戶端記憶體中幫它維護了整段對話歷史。
Claude 的 Messages API 本身是無狀態的(Stateless),伺服器端不會保留跨請求的對話 Session。每一次發送 HTTP 請求時,都必須傳送到目前為止的完整對話歷史。
因此,對話記憶在 CLI 端必須由客戶端自行掌管。我們在記憶體中維護一個訊息 slice,每一輪對話都依序追加:
type Session struct {
client anthropic.Client
messages []anthropic.MessageParam
}
func NewSession(client anthropic.Client) *Session {
return &Session{client: client}
}
func (s *Session) Ask(ctx context.Context, userInput string) error {
// 1. 寫入使用者輸入
s.messages = append(s.messages,
anthropic.NewUserMessage(anthropic.NewTextBlock(userInput)))
// 2. 傳送完整歷史並串流輸出
reply, err := s.stream(ctx)
if err != nil {
// 若 API 尚未輸出任何內容就出錯,撤回剛加入的 user message
if len(reply.Content) == 0 {
s.messages = s.messages[:len(s.messages)-1]
return err
}
// 串流中途停止時,保留已輸出給使用者看到的半句內容
s.messages = append(s.messages, reply.ToParam())
return err
}
// 3. 把完整回覆補回歷史,供下一輪使用
s.messages = append(s.messages, reply.ToParam())
return nil
}
func (s *Session) Reset() {
s.messages = nil
}
這個 Session 運作遵循三步節奏:寫入使用者訊息 → 傳送整段歷史 → 補回 AI 回覆。如果缺少第三步,下一輪傳送給 API 的歷史就會遺失 AI 剛才產生的回應。
當使用者輸入 /reset 命令時,Reset() 方法將 s.messages 設為 nil,即可在不安裝或更改任何伺服器端狀態的前提下清空對話。
若採用非串流的 HTTP 呼叫,在模型產生回應期間終端機畫面會完全靜止,使用者無法確定程式是否正常運作。串流模式會在模型產生文字片段時立刻輸出,大幅提升回應體感速度。
在接收串流事件時,CLI 需要同時完成兩件事:即時印出文字片段,並同步累積成完整訊息存回歷史紀錄。
func (s *Session) stream(ctx context.Context) (anthropic.Message, error) {
stream := s.client.Messages.NewStreaming(ctx, anthropic.MessageNewParams{
Model: anthropic.ModelClaudeSonnet5,
MaxTokens: 4096,
Messages: s.messages,
})
reply := anthropic.Message{}
for stream.Next() {
event := stream.Current()
// 同步累積成完整訊息,供後續轉存至對話歷史
reply.Accumulate(event)
// 即時印出收到的文字片段
if delta, ok := event.AsAny().(anthropic.ContentBlockDeltaEvent); ok {
if text, ok := delta.Delta.AsAny().(anthropic.TextDelta); ok {
fmt.Print(text.Text)
}
}
}
fmt.Println()
return reply, stream.Err()
}
reply.Accumulate(event) 與 fmt.Print(...) 在同一條迴圈中處理:累積後的完整訊息會存回歷史 slice,文字片段則立即顯示給使用者。
在單次命令中,只要執行過程拋出錯誤,程式通常會印出訊息並直接回傳 err 結束 Process。但在 REPL 模式中,如果單次網路抖動或 API 錯誤就導致整個程式崩潰,使用者累積的對話歷史也會一併遺失。
因此,在 REPL 主迴圈中需要建立兩層劃分明確的錯誤防線:
這兩層界線在 REPL 主迴圈中的完整實作如下:
func runChat(ctx context.Context, sess *chat.Session, in io.Reader, out io.Writer) error {
scanner := bufio.NewScanner(in)
for {
fmt.Fprint(out, "you› ")
// 進程級界線:標準輸入結束(EOF / Ctrl-D)或讀取失敗時才 return 退出進程
if !scanner.Scan() {
return scanner.Err()
}
line := strings.TrimSpace(scanner.Text())
switch line {
case "":
continue
case "/exit", "/quit":
return nil
case "/reset":
sess.Reset()
fmt.Fprintln(out, "(已清空對話)")
continue
}
fmt.Fprint(out, "ai › ")
// 輪次級界線:執行單輪對話
err := sess.Ask(ctx, line)
if err != nil {
// 發生網路或 API 錯誤時僅印出提示,不 return,繼續下一次迴圈
fmt.Fprintln(out, "錯誤:", err)
}
}
}
明確拆開這兩層界線後,單輪呼叫失敗只會影響當前回應,CLI Process 本身依然穩定運行,Session 歷史也不會受影響。
在一般終端機程式中,使用者按下 Ctrl-C 時,作業系統會發送 SIGINT 訊號,預設會直接終止整個程式 Process。
但在 Chat CLI 中,當 AI 的長回應輸出到一半時,使用者按下 Ctrl-C 通常只是想中斷當前這輪輸出並接著輸入下一句,而不是想直接關閉 CLI 工具。
為了達到這個效果,我們必須限制 Ctrl-C 訊號的作用範圍:不讓它終止整個 CLI Process,而是將它轉化為取消單輪 Context 的訊號。詳細的訊號與取消設計原則可參考 長時間 CLI 命令的進度、逾時與取消設計。
我們為每一輪對話獨立建立可取消的 turnCtx:
turnCtx, cancel := context.WithCancel(ctx)
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt)
go func() {
select {
case <-sigCh:
cancel() // 攔截到 Ctrl-C 時僅取消當前的 turnCtx
case <-turnCtx.Done():
// 當前輪次正常結束,背景 goroutine 自動退出
}
}()
err := sess.Ask(turnCtx, line)
signal.Stop(sigCh)
cancel()
if errors.Is(err, context.Canceled) {
fmt.Fprintln(out) // 使用者按下 Ctrl-C 時僅換行,回到下一次 prompt
continue
}
if err != nil {
fmt.Fprintln(out, "錯誤:", err) // 真正的 API 或網路錯誤才輸出錯誤訊息
}
背景 Goroutine 同時監聽 sigCh 與 turnCtx.Done()。當這輪對話正常完成時,cancel() 會關閉 turnCtx.Done(),使背景 Goroutine 自然退出,不會造成每一輪殘留未結束的 Goroutine。
當使用者按下 Ctrl-C 時,turnCtx 被取消,底層 API 串流停止,sess.Ask 捕獲 context.Canceled 錯誤。主迴圈經由 errors.Is(err, context.Canceled) 檢查後在終端機換行並執行 continue,順暢回到下一次輸入提示。
API 憑證應避免寫死在程式碼中或以命令列 Flag 傳遞。SDK 會自動讀取 ANTHROPIC_API_KEY 環境變數。
在 cmd/chat.go 中,我們建立 chat 子命令並將 Session 注入核心邏輯:
func newChatCmd() *cobra.Command {
return &cobra.Command{
Use: "chat",
Short: "開啟 Claude 對話",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
sess := chat.NewSession(anthropic.NewClient())
return runChat(
cmd.Context(),
sess,
cmd.InOrStdin(),
cmd.OutOrStdout(),
)
},
}
}
最後在 cmd/root.go 中透過 rootCmd.AddCommand(newChatCmd()) 註冊,即可透過 go run . chat 啟動終端機串流對話工具。
如果對本篇範例中出現的 Go 語法不熟悉,以下為相關特性的補充說明:
Go 的 Slice 是底層陣列的視窗。呼叫 append 會在尾端追加元素;使用 slice[:len-1] 語法可以迅速裁切掉最後一個元素。這個特性非常適合用來實作對話歷史撤回:
// 追加新的使用者訊息
s.messages = append(s.messages, userMsg)
// 若單輪發送失敗,透過裁切切除剛才追加的最後一筆訊息,還原對話歷史
s.messages = s.messages[:len(s.messages)-1]
在 Go 語言中,結構體的方法可以宣告為指標接收者 (s *Session) 或值接收者 (s Session)。若方法需要修改結構體內部的欄位狀態(如向 messages 切片追加內容),必須使用指標接收者:
// 使用指標接收者 (s *Session),對 s.messages 的修改才會反映至呼叫端
func (s *Session) Ask(ctx context.Context, userInput string) error {
s.messages = append(s.messages, userMsg)
return nil
}
若誤用值接收者 (s Session),Go 在呼叫該方法時會複製一份完整的 Session 副本,方法內對 s.messages 的修改會在函式結束時隨副本一同丟棄。
bufio.Scanner 與逐行串流讀取bufio.Scanner 是處理 io.Reader 逐行輸入的利器。每次呼叫 scanner.Scan() 都會讀取下一行並回傳 bool。當到達 EOF(如按下 Ctrl-D)或發生讀取錯誤時 Scan() 會回傳 false:
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
line := scanner.Text() // 取得當前行的字串內容(不含換行符)
if line == "/exit" {
break
}
}
if err := scanner.Err(); err != nil {
// 處理真正的輸入讀取錯誤
}
在處理 SDK 的動態 Event 串流時,事件物件通常以通用介面(如 any)傳遞。透過 .(TargetType) 語法搭配 ok 檢查,能精準提取特定的 Event 結構體並取得文字片段:
// 將通用介面安全的轉型為具體的 ContentBlockDeltaEvent 結構體
if delta, ok := event.AsAny().(anthropic.ContentBlockDeltaEvent); ok {
if text, ok := delta.Delta.AsAny().(anthropic.TextDelta); ok {
fmt.Print(text.Text)
}
}